iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0

現在使用者已經可以透過右上角 UI 控制整個太陽系。
今天要進一步把操作方式從操作 UI 變成直接操作 3D Scene
也就是讓使用者可以直接用滑鼠點擊行星
當某顆行星被選中時,它所對應的軌道會變色,而其他軌道維持原本的灰色。

由於目前專案程式碼已逐漸增加,從 Day 23 開始文章只列出當日新增或修改的程式碼。
完整整合版將於 Day 30 最終完成時提供。

一、今天完成目標

今天會完成:

  • 認識 THREE.Raycaster
  • 取得滑鼠在畫面上的位置
  • 將滑鼠座標轉成 Three.js 使用的座標
  • 判斷滑鼠點到了哪一顆行星
  • 儲存目前選中的 Planet
  • 讓被選中的 Planet Orbit 改變顏色
  • 切換其他 Planet 時恢復原本灰色軌道
  • 點擊空白區域時取消選取
  • 為 Day 27 的 Planet Focus 與 Planet Info 做準備

二、本日修改摘要

項目 修改內容
Planet Orbit 除了 Orbit 本身,再保存 Planet Name
Raycaster 新增 THREE.Raycaster()
Mouse 新增 THREE.Vector2()
Click 判斷使用者點到哪顆行星
Orbit Color 選中的軌道改成黃色
Empty Space 點擊空白區域取消選取

項目 修改內容
Planet Orbit 除了 Orbit 本身,再保存 Planet Name
Raycaster 新增 THREE.Raycaster()
Mouse 新增 THREE.Vector2()
Click 判斷使用者點到哪顆行星
Orbit Color 選中的軌道改成黃色
Empty Space 點擊空白區域取消選取

今天完成之後原本所有 Orbit → 灰色
點擊 Earth Orbit:

Mercury Orbit → 灰色
Venus Orbit → 灰色
Earth Orbit → 黃色
Mars Orbit → 灰色
...

三、什麼是 Raycaster?

今天最重要的新工具 THREE.Raycaster
可以把它想像成:
Camera
↓
滑鼠點擊位置
↓
發射一條看不見的射線
↓
穿過 3D Scene
↓
檢查碰到了哪些 Object

如果這條 Ray 穿過 Earth,就代表:
使用者點到了 Earth

理解它是麼之後,我們先建立 Raycaster 首先要新增:

const raycaster =
    new THREE.Raycaster();

接著建立一個用來保存滑鼠位置的:

const mouse =
    new THREE.Vector2();

四、瀏覽器座標和 Three.js 不一樣

滑鼠只需要 X Y 不需要 Z
所以使用 THREE.Vector2 而不是 THREE.Vector3
瀏覽器取得的 Mouse Position 通常是:
左上角 (0, 0)

例如螢幕寬度:1920
滑鼠可能是 X = 960 Y = 500
但 Raycaster 不直接使用這個座標。
Three.js 要使用的是:
X = -1 ~ 1
Y = -1 ~ 1
這種座標叫做 NDC(Normalized Device Coordinates)

五、把 Mouse 轉成 NDC

先取得 Canvas 的範圍:

const rect =
    renderer.domElement
        .getBoundingClientRect();

接著:

mouse.x =
    (
        (
            event.clientX -
            rect.left
        ) /
        rect.width
    ) *
    2 -
    1;

Y 比較特別:

mouse.y =
    -(
        (
            event.clientY -
            rect.top
        ) /
        rect.height
    ) *
    2 +
    1;

注意 mouse.y 前面有 '-'
原因是瀏覽器 Y → 往下增加
Three.js NDC Y → 往上增加
方向剛好相反。

六、Ray 從 Camera 發出去

完成 Mouse Position 後:

raycaster.setFromCamera(
    mouse,
    camera
);

意思就是 mouse + camera 建立 → Ray
這條 Ray 會從 Camera 出發,穿過滑鼠點擊的位置。

七、要檢查哪些 Object?

我們不需要讓 Raycaster 檢查:

Sun
Orbit
Stars
Moon
Saturn Ring
Venus Atmosphere

今天只想選擇 八大行星

而我們本來就有:

const planets =
    [];

裡面每個 Planet 都保存:

{
    name:
        data.name,

    mesh:
        planet,

    ...
}

所以可以取得:

const planetMeshes =
    planets.map(
        (planet) =>
            planet.mesh
    );

得到:
[
Mercury Mesh,
Venus Mesh,
Earth Mesh,
Mars Mesh,
Jupiter Mesh,
Saturn Mesh,
Uranus Mesh,
Neptune Mesh
]

八、檢查 Ray 有沒有碰到 Planet

使用:

const intersects =
    raycaster.intersectObjects(
        planetMeshes,
        false
    );

如果 intersects.length > 0 代表有點到 Planet。
第一個碰到的 intersects[0] 通常就是離 Camera 最近的 Object。
所以:

const selectedMesh =
    intersects[0].object;

九、找出是哪一顆 Planet

我們取得的是 Mesh
但真正需要知道的是
它是 Earth? Mars? Saturn?
所以可以:

const selectedPlanet =
    planets.find(
        (planet) =>
            planet.mesh ===
            selectedMesh
    );

這樣 selectedPlanet.name 就可能得到 Earth 或 Saturn

十、Orbit 也要保存 Planet Name

Day 24 為了控制 Orbit 顯示與隱藏,我們原本保存:

planetOrbits.push(
    orbit
);

今天需要知道這條 Orbit 是誰的?
所以把它改成:

planetOrbits.push({

    name:
        data.name,

    line:
        orbit

});

十一、Day 24 的 Orbit Toggle 也要小改

因為以前 planetOrbits 裡面直接放 LineLoop
現在變成 Object 所以原本:

orbit.visible =
    orbitToggle.checked;

要改成:

orbit.line.visible =
    orbitToggle.checked;

完整:

planetOrbits.forEach(
    (orbit) => {

        orbit.line.visible =
            orbitToggle.checked;

    }
);

十二、建立 Orbit 顏色

今天準備兩個顏色:

const defaultOrbitColor =
    0x444444;

const selectedOrbitColor =
    0xffcc66;

你之後想換成 藍色紅色綠色 都可以。

十三、先把所有 Orbit 恢復灰色

建立:

function resetOrbitColors() {

    planetOrbits.forEach(
        (orbit) => {

            orbit.line.material.color.setHex(
                defaultOrbitColor
            );

        }
    );

}

每次點擊新的 Planet 前 resetOrbitColors();
所以上一顆選中的 Orbit 恢復灰色

十四、Highlight Selected Orbit

再建立:

function highlightOrbit(
    planetName
) {

    const orbit =
        planetOrbits.find(
            (orbit) =>
                orbit.name ===
                planetName
        );


    if (
        orbit
    ) {

        orbit.line.material.color.setHex(
            selectedOrbitColor
        );

    }

}

例如:

highlightOrbit(
    "Earth"
);

Earth Orbit 就會變黃色。

十五、加入 Pointer Event

今天直接監聽:

renderer.domElement.addEventListener(
    "pointerdown",
    ...
);

而不是 window.addEventListener(...)
原因是我們只希望 點擊 3D Canvas 才觸發 Raycaster。
這樣點擊右上角 UI 選單就不會意外選到 Canvas 後面的 Planet。

十六、完整 Planet Selection

今天最重要的新程式是:

renderer.domElement.addEventListener(
    "pointerdown",
    (event) => {

        const rect =
            renderer.domElement
                .getBoundingClientRect();


        mouse.x =
            (
                (
                    event.clientX -
                    rect.left
                ) /
                rect.width
            ) *
            2 -
            1;


        mouse.y =
            -(
                (
                    event.clientY -
                    rect.top
                ) /
                rect.height
            ) *
            2 +
            1;


        raycaster.setFromCamera(
            mouse,
            camera
        );


        const planetMeshes =
            planets.map(
                (planet) =>
                    planet.mesh
            );


        const intersects =
            raycaster.intersectObjects(
                planetMeshes,
                false
            );


        resetOrbitColors();


        if (
            intersects.length >
            0
        ) {

            const selectedMesh =
                intersects[0].object;


            const selectedPlanet =
                planets.find(
                    (planet) =>
                        planet.mesh ===
                        selectedMesh
                );


            if (
                selectedPlanet
            ) {

                highlightOrbit(
                    selectedPlanet.name
                );


                console.log(
                    "Selected:",
                    selectedPlanet.name
                );

            }

        }

    }
);

十七、點擊空白區域

這裡有一個小細節。
我們在判斷以前就先執行 resetOrbitColors();

因此點 Earth → Earth Orbit 黃色
接著點空白resetOrbitColors() → 全部恢復灰色
沒有 intersect → 沒有新的 Highlight
所以就完成取消選取不用另外做按鈕。

十八、目前為什麼只改 Orbit 顏色?

今天其實也可以讓 Planet 本身發光、放大、改顏色
但我們先不要一次加入太多效果。

Day 26 的目的主要是學會:
Mouse
↓
Raycaster
↓
3D Object

所以今天用 Orbit Highlight
當作最明顯的選取回饋。

十九、今天小結

今天最重要的新工具就是 THREE.Raycaster
以及 raycaster.intersectObjects()
現在使用者已經可以
點 Mercury → Mercury Orbit Highlight
點 Earth → Earth Orbit Highlight
而其他 Orbit 維持灰色

這就完成了第一個真正的:
3D Object Selection

Day 27 預告

「Planet Focus × Planet Info:鎖定行星與資訊面板」

今天我們已經知道使用者點了哪顆 Planet
Day 27 就可以把這個資訊真正拿來使用。
例如點擊Earth → Camera 會鎖定 Earth
而且 Earth 還在公轉,所以 Camera 的 Target 必須每一幀跟著 Earth 更新
接著 UI 也會顯示 Earth

中文:地球
距離太陽:第三顆行星
衛星:Moon
公轉速度:0.8
並加入:返回太陽系
讓 Camera 回到原本的:Sun Center Mode

Day 27 會是目前互動效果最大的一次升級。


上一篇
Day 25 - Animation Controls:Play、Pause × Speed
下一篇
Day 27 - Planet Focus × Planet Info:鎖定行星與資訊面板
系列文
從 Web 到宇宙:Three.js × WebGL 互動式太陽系與光影實作 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言